Skip to content

[Turnstile] Spin V2 post-launch tidy-up: prompt.md hotfix + docs backlog#32147

Open
juleslemee wants to merge 23 commits into
cloudflare:productionfrom
juleslemee:spin-docs-followup
Open

[Turnstile] Spin V2 post-launch tidy-up: prompt.md hotfix + docs backlog#32147
juleslemee wants to merge 23 commits into
cloudflare:productionfrom
juleslemee:spin-docs-followup

Conversation

@juleslemee

@juleslemee juleslemee commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Context

Follow-up to #31517 (Spin V2 docs pivot to canonical siteverify, merged 2026-06-17). This PR ships 15 commits of prompt.md tidy-up work plus a token-reset hygiene fix as the primary motivator, plus the five helper scripts referenced by the Spin skill flow.

Client-side token-reset hygiene

Spin V2 went to public production on 2026-07-14. The first ~3 days of telemetry showed elevated timeout-or-duplicate failures on the canonical siteverify path:

V2 (first 3 days) V1 (same-elapsed baseline)
sv_ok / sv_fail 2,426 / 124 8,721 / 85
Failure rate 4.86% 0.97%
Double-redemption share of failures 99% (123/124) Very small

Root cause on the client side: cf-turnstile-response tokens are single-use. When siteverify returns success: false and the user retries in-page, the browser resubmits the same DOM-cached token and the edge rejects the second attempt. V1's managed-Worker path owned client-side widget lifecycle; V2's canonical siteverify pattern hands lifecycle to the customer's frontend, and the docs didn't call out this contract.

The needle-mover here shipped on the server side: siteverify release 2026.7.3 (tagged 2026-07-20) cut V2's error rate from 16.4% at Sunday's traffic peak to 2.35% today on 51K events. V1 is running at 4.5% today over the same window, so V2 is now comfortably below V1.

This PR's client-side changes remain worth landing as hygiene: without them, agent-generated customer code keeps producing the redundant-siteverify pattern that 2026.7.3 has to absorb server-side. With them, agents emit the correct client-side contract from the start.

Fix: the canonical HTML snippet in the 'The frontend-edit contract' section now includes id="cf-form" and a setTimeout(() => window.turnstile?.reset(), 0) submit listener. A new Token lifecycle paragraph explains the single-use contract and points at the per-framework reset patterns in the six references (vanilla-html, nextjs-app, nextjs-pages, sveltekit, astro, hugo).

Helper scripts

public/turnstile/spin/scripts/ gains 5 deterministic helper scripts that the Spin agent invokes and branches on. They own the API-call and error-handling logic so the SKILL.md text can stay focused on orchestration.

Script Purpose Wizard step
auth-probe.sh Enumerate Cloudflare accounts on the token and verify Turnstile:Edit scope on the selected account. Falls back to $CLOUDFLARE_ACCOUNT_ID when wrangler isn't installed. Step 3
widget-create.sh Create a Turnstile widget via POST /accounts/:id/challenges/widgets; return sitekey + secret. Step 8 (fallback for the wrangler turnstile widget create primary path)
validate.sh Two-check post-integration validation: dummy-token siteverify (confirms secret binding) + optional widget-domains sanity check (confirms hostnames registered). Step 10
persist-skill.sh degit the canonical Spin bundle from cloudflare/skills into the user's repo so the agent can re-load the skill on follow-up tasks. Step 11
fetch-secret.sh Recovery-flow only: look up an existing widget by sitekey and return its secret + clearance level + registered domains, without rotating the sitekey. Recovery Step 2

Each script emits structured JSON on stdout, human diagnostics on stderr, and exit-codes the agent can branch on. All 5 pass bash -n and were hardened in response to the docs-bot's first review (unsafe JSON interpolation, unbound-variable handling, mktemp portability, command -v wrangler guard, URL encoding, structured Python fallbacks); the fixes are captured in the triage comment.

Everything else in this PR (in commit order)

  • Set autocomplete hints on client-side rendering examples (VULN-140143) — accessibility follow-up
  • Spin: document dashboard setup path alongside command-line — dashboard-led Spin flow
  • Spin docs: address style-bot suggestions — style bot review feedback
  • Spin V2 docs: point references at prompt.md — reference URL update
  • Spin V2 docs: dashboard recovery is the primary path, agent recovery is the fallback — reorder recovery flow priority
  • Spin docs: pivot to canonical siteverify — (already merged in [Turnstile] Spin docs: pivot to canonical siteverify #31517, kept in branch history)
  • Spin V2 sync: prompt.md + Wrangler CLI setup path — add wrangler CLI option in Step 8
  • Drop Wrangler version note from Spin CLI section — version note was outdated
  • Fix Spin docs accuracy (removed column, local dev domains, marker wording) — small accuracy fixes
  • Merge remote-tracking branch upstream/production — periodic sync
  • Drop Spin beta tag; bump reviewed date — V2 is out of beta
  • Redirect old Spin index.md to prompt.md + revert — redirect was too aggressive
  • Spin docs: spell out insertion-preference examples per review feedback — reviewer asked for examples

Companion PRs

The three Spin surfaces (canonical SKILL.md, docs prompt.md, dashboard-inlined spinSkillContent.ts) must stay in lockstep.

Rollout

Draft opened Friday 2026-07-17. With siteverify 2026.7.3 already shipping the server-side fix on Monday and V2 running comfortably below V1 (2.35% vs 4.5% today), this PR can land at normal review speed as product-hygiene rather than a hotfix. Dashboard: https://grafana.cfdata.org/d/dfscir1b3r4sgc.

jules lemee added 15 commits June 16, 2026 20:55
The dashboard 'Set up with Spin' button orchestrates widget + Worker
creation server-side; this commit teaches the docs about it.

- spin.mdx restructured to lead with the dashboard path (DashButton + 5
  steps), demote the command-line agent flow to an alternative for users
  without dashboard access or who prefer to drive setup from their editor.
- New 'Wire up the frontend' section with the HTML + JS pattern for
  dashboard-deployed widgets (sitekey + Worker URL handed off, agent
  prompt or hand-wired).
- Telemetry marker section documents both data-action values:
  turnstile-spin-v1 (command-line) and turnstile-spin-v2 (dashboard).
- Recovery section mentions the in-dashboard 'Fix all with Spin' banner
  alongside the agent-led recovery prompt.
- Agent-step table converted from em-dash separators to parentheses;
  agent skill index.md style-only tweaks (root-relative URLs, no
  contractions, imperative heading).
- Refer to (not 'See') for the in-page link to Telemetry marker.
- Convert the two-item 'edge cases to flag' bullet list to prose.
- Replace passive 'Both markers are applied automatically by the
  respective setup path' with active 'Each setup path applies its own
  marker automatically'.
Match the rename in cloudflare#31532. The agent skill route is moving from
/turnstile/spin/index.md to /turnstile/spin/prompt.md; align the V2
docs PR with that so the diff stays clean when cloudflare#31532 merges first.
Rewrite developers.cloudflare.com/turnstile/spin to match the new
Spin Lite architecture: dashboard creates the widget, agent wires
canonical server-side siteverify in the customer's existing backend.
No managed Worker.

Drops the 'Managed Worker endpoints' and 'Worker configuration'
reference tables. Updates wire-up examples to canonical
challenges.cloudflare.com/turnstile/v0/siteverify. Updates recovery
and migration sections to match the no-Worker shape.

Pairs with cloudflare/skills and cloudflare/fe/stratus !39261.
- Rename public/turnstile/spin/{index.md → prompt.md} so the URL matches
  the four references from spin.mdx (previously 404'd in production).
- Sync prompt.md from the canonical V2 SKILL in cloudflare/skills: drops
  the Worker deploy step, wires canonical siteverify from the customer's
  existing backend, uses turnstile-spin-v2 marker.
- Add 'Set up from the Wrangler CLI' section documenting
  'wrangler turnstile widget create/list/get/update/delete' (Wrangler
  4.109+, alpha).
- Rename 'Set up from the command line' → 'Set up from an AI coding
  agent' now that there are three setup paths.
- prompt.md now also documents the wrangler-first fallback in Step 8.
Version-gate notes are useful for the narrow window right after a new
Wrangler version ships; once the release stabilizes they read as noise
and the number ages. Docs stay accurate without pinning to 4.109.
…r V2

Combines two changes in the docs mirror of the Spin skill:

1. Sync prompt.md to the current cloudflare/skills SKILL.md (em-dash cleanup,
   'Do not propose features' bullet added to Things you must NOT do, recovery
   flow line 4 wording updated to match SKILL.md, redundant backend snippet
   dropped since it now lives only in Step 9).

2. Token-reset hotfix. Turnstile tokens are single-use; when siteverify
   returns success:false and the user retries, the browser resubmits the
   same cf-turnstile-response and Cloudflare's edge rejects it as
   timeout-or-duplicate. V2 telemetry since the 2026-07-14 launch shows
   4.86% sv_fail rate, 99% of which are double redemptions (5x V1). The
   canonical HTML snippet in 'The frontend-edit contract' now gets
   id=cf-form + a setTimeout reset-on-submit listener, plus a new
   'Token lifecycle' paragraph explaining the single-use contract.

Companion PRs:
- cloudflare/skills#83 (canonical SKILL.md + 6 framework references)
- stratus (spinSkillContent.ts template-literal mirror)
@cloudflare-docs-bot

cloudflare-docs-bot Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Review

⚠️ Rebase: Rebase skipped — cannot push to fork branches. The PR author must rebase locally.
🚨 5 critical, ⚠️ 2 warnings, 💡 1 suggestion found in commit cf1d4e5.

👉 Fix in your agent 👈
Fix the following review findings in PR #32147 (https://github.com/cloudflare/cloudflare-docs/pull/32147).

Before making changes, review each finding and present a brief summary table:
- For each finding, state whether you agree, disagree, or need clarification
- If you disagree (e.g. the fix requires disproportionate effort for minimal benefit,
  or the finding is factually incorrect), explain why
- If you need clarification before deciding, ask those questions
- Then share your plan for which issues to tackle and in what order

After triaging, follow this order:
1. Post a comment on this PR for any findings you are skipping, with the finding ID and your reasoning.
2. Then commit the fixes for the legitimate findings.

The comment must come before the commit — the bot reads PR comments when a new
push triggers a review, so skip comments posted after the push will be missed.

---

## Code Review

### Critical (5)

#### CR-3a6de4aa4109 · Wrong backslash escaping breaks emitted JSON
- **File:** `public/turnstile/spin/prompt.md` line 269
- **Issue:** auth-probe.sh uses `emit "{\\"status\\":...}"` (two literal backslashes before each quote). In bash double quotes `\\"` expands to a literal backslash followed by a quote, producing output like `{\"status\":\"account_mismatch\"...}` instead of valid JSON.
- **Fix:** Use `\"` (single backslash) in double-quoted strings, or safer: emit static JSON from a here-doc and pipe through jq for variable interpolation (e.g., `jq -n --arg s "$status" '{status:$s}'`). This same bug repeats at lines 276, 289, and 292 in the same script.

#### CR-02a9ec797c54 · Wrong backslash escaping breaks emitted JSON
- **File:** `public/turnstile/spin/prompt.md` line 343
- **Issue:** fetch-secret.sh uses `echo "{\\"status\\":\\"ok\\",...}"` which emits invalid JSON with literal backslashes before each quote.
- **Fix:** Fix escaping to `\"` or build JSON with jq. Same bug occurs at lines 353 and 359.

#### CR-9d54c498cdad · Wrong backslash escaping breaks emitted JSON
- **File:** `public/turnstile/spin/prompt.md` line 393
- **Issue:** persist-skill.sh uses `echo "{\\"status\\":\\"error\\",...}"` which will produce malformed JSON output.
- **Fix:** Fix escaping or use jq. Same bug occurs at lines 399 and 409.

#### CR-f2f219b68135 · Wrong backslash escaping breaks emitted JSON
- **File:** `public/turnstile/spin/prompt.md` line 466
- **Issue:** validate.sh uses `echo "{\\"status\\":\\"error\\",...}"` for all failure/success JSON, so the exit-status contract is unreadable by the caller.
- **Fix:** Fix escaping to `\"` or use jq. Same bug occurs at lines 473, 481, and 510.

#### CR-2bd87245adf0 · Wrong backslash escaping sends invalid JSON to API
- **File:** `public/turnstile/spin/prompt.md` line 561
- **Issue:** widget-create.sh passes `-d "{\\"name\\":\\"$NAME\\",...}"` to curl. The shell expands `\\"` to literal backslash+quote, so the HTTP body is not valid JSON and the Cloudflare API call will fail.
- **Fix:** Build the body with jq (e.g., `jq -n --arg n "$NAME" --argjson d "$domains_json" --arg m "$MODE" '{name:$n,domains:$d,mode:$m}'`) and pass the resulting variable. Same escaping bug also affects the stdout emits at lines 568 and 575.

### Warnings (2)

#### CR-52375cfc97a7 · Unconditional response parsing in siteverify example
- **File:** `public/turnstile/spin/prompt.md` line 76
- **Issue:** The canonical Node/fetch snippet calls `await r.json()` without checking `r.ok` first. If challenges.cloudflare.com returns a non-JSON error response, the example code throws instead of failing cleanly.
- **Fix:** Add `if (!r.ok) { /* handle transport/site error */ }` before parsing, or wrap in try/catch.

#### CR-652f946d9a49 · Undefined function in siteverify example
- **File:** `public/turnstile/spin/prompt.md` line 78
- **Issue:** The snippet returns `reject(403, 'forbidden');`. `reject` is not defined in Node/Express/fetch handler context and would throw a ReferenceError if copied verbatim.
- **Fix:** Use a concrete platform example (e.g., `return res.status(403).send('forbidden')` for Express, or `return new Response('forbidden', {status:403})` for Workers) rather than an undefined placeholder.

### Suggestions (1)

#### CR-e2553868311b · Duplicated error response string
- **File:** `public/turnstile/spin/scripts/fetch-secret.sh` line 64
- **Issue:** The JSON error response `{"status":"error","reason":"mktemp_failed"}` is repeated verbatim in both mktemp failure handlers (lines 64 and 69). If the response schema changes, both locations must be kept in sync.
- **Fix:** Extract the error response to a shell variable or small helper function so the mktemp-failure response is defined in one place.

Code Review

This code review is in beta and may not always be helpful — use your judgment.

Critical (5)
File Issue
public/turnstile/spin/prompt.md line 269 Wrong backslash escaping breaks emitted JSON — auth-probe.sh uses emit "{\\"status\\":...}" (two literal backslashes before each quote). In bash double quotes \\" expands to a literal backslash followed by a quote, producing output like {\"status\":\"account_mismatch\"...} instead of valid JSON. Fix: Use \" (single backslash) in double-quoted strings, or safer: emit static JSON from a here-doc and pipe through jq for variable interpolation (e.g., jq -n --arg s "$status" '{status:$s}'). This same bug repeats at lines 276, 289, and 292 in the same script.
public/turnstile/spin/prompt.md line 343 Wrong backslash escaping breaks emitted JSON — fetch-secret.sh uses echo "{\\"status\\":\\"ok\\",...}" which emits invalid JSON with literal backslashes before each quote. Fix: Fix escaping to \" or build JSON with jq. Same bug occurs at lines 353 and 359.
public/turnstile/spin/prompt.md line 393 Wrong backslash escaping breaks emitted JSON — persist-skill.sh uses echo "{\\"status\\":\\"error\\",...}" which will produce malformed JSON output. Fix: Fix escaping or use jq. Same bug occurs at lines 399 and 409.
public/turnstile/spin/prompt.md line 466 Wrong backslash escaping breaks emitted JSON — validate.sh uses echo "{\\"status\\":\\"error\\",...}" for all failure/success JSON, so the exit-status contract is unreadable by the caller. Fix: Fix escaping to \" or use jq. Same bug occurs at lines 473, 481, and 510.
public/turnstile/spin/prompt.md line 561 Wrong backslash escaping sends invalid JSON to API — widget-create.sh passes -d "{\\"name\\":\\"$NAME\\",...}" to curl. The shell expands \\" to literal backslash+quote, so the HTTP body is not valid JSON and the Cloudflare API call will fail. Fix: Build the body with jq (e.g., jq -n --arg n "$NAME" --argjson d "$domains_json" --arg m "$MODE" '{name:$n,domains:$d,mode:$m}') and pass the resulting variable. Same escaping bug also affects the stdout emits at lines 568 and 575.
Warnings (2)
File Issue
public/turnstile/spin/prompt.md line 76 Unconditional response parsing in siteverify example — The canonical Node/fetch snippet calls await r.json() without checking r.ok first. If challenges.cloudflare.com returns a non-JSON error response, the example code throws instead of failing cleanly. Fix: Add if (!r.ok) { /\* handle transport/site error \*/ } before parsing, or wrap in try/catch.
public/turnstile/spin/prompt.md line 78 Undefined function in siteverify example — The snippet returns reject(403, 'forbidden');. reject is not defined in Node/Express/fetch handler context and would throw a ReferenceError if copied verbatim. Fix: Use a concrete platform example (e.g., return res.status(403).send('forbidden') for Express, or return new Response('forbidden', {status:403}) for Workers) rather than an undefined placeholder.
Suggestions (1)
File Issue
public/turnstile/spin/scripts/fetch-secret.sh line 64 Duplicated error response string — The JSON error response {"status":"error","reason":"mktemp_failed"} is repeated verbatim in both mktemp failure handlers (lines 64 and 69). If the response schema changes, both locations must be kept in sync. Fix: Extract the error response to a shell variable or small helper function so the mktemp-failure response is defined in one place.

Conventions

No convention issues found.

Style Guide Review

No style-guide issues found.

Commands

Only codeowners can run commands. Post a comment with the command to trigger it.

Command Description
/review Runs a review now. Incremental if a prior review exists, full if not.
/full-review Re-reviews the entire PR diff from scratch, ignoring incremental history. Useful after a rebase, when you want a fresh review, or if the bot gets out of sync and reports issues that no longer exist.
/ignore-review-limit Permanently lifts the 2-review automatic limit for this PR. Future pushes will trigger reviews as normal.
/disable-auto-review Stops automatic reviews from triggering on future pushes to this PR. Codeowners can still run /review or /full-review manually.
/rebase Rebases the PR branch against production. On conflict, attempts to resolve automatically using AI. Stops with an explanation if confidence is not high enough.

…arnings

Resolves the prompt.md merge conflict between this branch's V2
token-reset hotfix and production's Jul 10 canonical siteverify
pivot. All conflict blocks resolve to HEAD; the branch's tidy-ups
(no em-dashes, dashboard Deployment column removed, redundant
'## Do not' section consolidated into 'Things you must NOT do')
already superseded the production text.

Also addresses three warnings from cloudflare-docs-bot on this PR:

* CR-b07f3bb2e801 (missing helper scripts): commit auth-probe.sh,
  fetch-secret.sh, persist-skill.sh, validate.sh, widget-create.sh
  to public/turnstile/spin/scripts/ so the docs page is
  self-contained. Same content as the cloudflare/skills bundle.

* CR-79e4bf260a6b (contradictory pre-clearance handling): recovery
  flow step 3 no longer offers 'ask user or exit' for
  non-no_clearance widgets, which contradicted the scope boundary.
  Now always exits per the boundary.

* CR-223fc933d2c8 (missing HTTP error handling in siteverify
  snippet): wrap fetch+json in try/catch, check r.ok, replace the
  non-existent reject(403, ...) helper with res.status(403).send()
  and a note about adapting to the target framework.
juleslemee pushed a commit to juleslemee/skills that referenced this pull request Jul 21, 2026
Mirrors the fixes applied in cloudflare/cloudflare-docs#32147:

* Siteverify snippet in Step 9 now wraps fetch+json in try/catch,
  checks r.ok, and replaces the non-existent reject(403, ...) helper
  with res.status(403).send('forbidden') plus a comment about
  adapting to the target framework.

* Recovery flow step 3 no longer offers 'ask user or exit' for
  non-no_clearance widgets, which contradicted the scope boundary.
  Always exits per the boundary now.

Keeps this file byte-for-byte in sync with the docs prompt.md and
stratus spinSkillContent.ts (the three-surface parity that the
Spin machinery depends on).
@juleslemee

Copy link
Copy Markdown
Contributor Author

Triaged all 3 findings from the code review; all fixed in merge commit 7995c2d.

ID Decision Change
CR-b07f3bb2e801 Fix Committed the 5 helper scripts (auth-probe.sh, fetch-secret.sh, persist-skill.sh, validate.sh, widget-create.sh) to public/turnstile/spin/scripts/ so the docs page is self-contained. Same content as the cloudflare/skills bundle.
CR-79e4bf260a6b Fix Recovery flow step 3 no longer offers 'ask user or exit' for non-no_clearance widgets. Always exits per the scope boundary at line 125 now.
CR-223fc933d2c8 Fix Wrapped fetch+json in try/catch, added r.ok check, replaced the non-existent reject(403, 'forbidden') helper with res.status(403).send('forbidden') plus a comment about adapting to the target framework.

Also propagated the CR-79e4bf260a6b and CR-223fc933d2c8 fixes to the two companion PRs to keep the three-surface skill content in sync:

Comment thread public/turnstile/spin/prompt.md Outdated
Comment thread public/turnstile/spin/prompt.md Outdated
juleslemee pushed a commit to juleslemee/skills that referenced this pull request Jul 21, 2026
Marina flagged on cloudflare/cloudflare-docs#32147 that the skill's
scope reads too form-centric, when Turnstile actually applies to any
user-triggered request the customer wants to gate: form submissions,
SPA button-triggered API calls, download links, comment or vote
submissions, and any other explicit user action that hits a backend
the customer controls.

Broadened six spots to match how Turnstile is actually used in the
wild (per the product docs at developers.cloudflare.com/turnstile),
while keeping the canonical form example in the frontend-edit
contract section as the primary illustration:

* Description: 'embed it on the right forms' -> 'embed it where user
  requests need bot verification (form submissions, SPA actions, API
  endpoints, download links, comment or vote submissions, etc.)'.
* When-to-load triggers: added 'protect this endpoint', 'protect this
  button', 'block bots on <target>'; the 'specific request' example
  bullet now lists downloads / comments / API endpoints alongside
  signup / login / contact form.
* Brief-acknowledge line the agent says on Step 1.
* Wire-the-integration statement the agent says on Step 9.
* Hard-scope-boundary sentence: dropped 'form' qualifier on handler.
* Frontend-edit-contract opener: covers form or user-triggered
  endpoint; dropped 'submit' from handler.

Kept in sync across all three surfaces (this canonical SKILL.md,
prompt.md docs, stratus spinSkillContent.ts).
Marina flagged on this PR that the skill's scope reads too
form-centric, when Turnstile actually applies to any user-triggered
request the customer wants to gate: form submissions, SPA
button-triggered API calls, download links, comment or vote
submissions, and any other explicit user action that hits a backend
the customer controls.

Broadened six spots to match how Turnstile is actually used in the
wild (per the product docs at developers.cloudflare.com/turnstile),
while keeping the canonical form example in the frontend-edit
contract section as the primary illustration:

* Description: 'embed it on the right forms' -> 'embed it where user
  requests need bot verification (form submissions, SPA actions, API
  endpoints, download links, comment or vote submissions, etc.)'.
* When-to-load triggers: added 'protect this endpoint', 'protect this
  button', 'block bots on <target>'; the 'specific request' example
  bullet now lists downloads / comments / API endpoints alongside
  signup / login / contact form.
* Brief-acknowledge line the agent says on Step 1.
* Wire-the-integration statement the agent says on Step 9.
* Hard-scope-boundary sentence: dropped 'form' qualifier on handler.
* Frontend-edit-contract opener: covers form or user-triggered
  endpoint; dropped 'submit' from handler.

Kept in sync across all three surfaces (this prompt.md,
cloudflare/skills SKILL.md, stratus spinSkillContent.ts).

Companion commits:
- cloudflare/skills#83: 8192e1d
- stratus MR !40817: 399fbbbd1e0
@juleslemee
juleslemee requested a review from marinaelmore July 21, 2026 21:22
Address all 19 findings from cloudflare-docs-bot on this PR: 1
critical + 15 warnings + 1 suggestion + 1 convention (PR description
scope, updated separately).

widget-create.sh:
- CR-22ef8f6771b1 (critical): build request body via python3
  json.dumps instead of hand-escaping quotes into a raw JSON string
- CR-f735353f7ad5: argument value validation before shift
- CR-e48c7d0bb1e9: drop `2>/dev/null` on the API curl so network
  errors reach stderr
- CR-a9681e1b8608, CR-5fd7af1e876a: build success and error output
  via python3 json.dumps

validate.sh:
- CR-e781359b7c28: argument value validation
- CR-afe9e2b7b7c0: mktemp + %{http_code} pattern with explicit
  status check for the widget-domains lookup
- CR-391c70408be0: python3 parse with structured error handling

auth-probe.sh:
- CR-1f8cc5f1475c: `command -v wrangler` guard before invocation,
  fall back to $CLOUDFLARE_ACCOUNT_ID when wrangler is unavailable
- CR-c5afb0714a2f: replace GET-based scope probe with
  POST-invalid-payload probe to test Turnstile:Edit specifically

fetch-secret.sh:
- CR-e82849253dd7: argument value validation
- CR-30730b7afe35: mktemp with explicit template for macOS/BSD
- CR-1bd6e05a63d9 (suggestion): trap-based temp cleanup
- CR-2a35782321aa: Python .get() fallbacks with structured errors
- CR-76a6232130e9: URL-encode account_id and sitekey

persist-skill.sh:
- CR-03ee17a69501: argument value validation
- CR-bb0ae4241b8d: build scripts JSON list via python3
  os.listdir + json.dumps

All scripts pass 'bash -n' and end-to-end smoke tests against a
non-existent account (structured error output, no tracebacks). Same
5 scripts committed to cloudflare/skills at
cloudflare/skills#83 and inlined into
stratus MR !40817.
@juleslemee

Copy link
Copy Markdown
Contributor Author

Triaged all 19 findings on the helper scripts; agreed with all, fixing in the pending commit.

ID File:Line Decision Change
CR-22ef8f6771b1 (critical) widget-create.sh:42 Fix Build request body via python3 json.dumps (replaces hand-escaped JSON that broke on names/modes containing ", \, or control chars).
CR-f735353f7ad5 widget-create.sh:23 Fix need_arg helper: guard $2 before shift so a trailing flag prints a clear usage error instead of set -u unbound-variable abort.
CR-e48c7d0bb1e9 widget-create.sh:42 Fix Drop 2>/dev/null on the API curl. Header comment says diagnostics go to stderr; now they actually do.
CR-a9681e1b8608 widget-create.sh:49 Fix Success output built via python3 json.dumps instead of hand-escaped echo.
CR-5fd7af1e876a widget-create.sh:56 Fix Error output built via python3 json.dumps; drop the tr -d '"' that was mangling legitimate quoted messages.
CR-e781359b7c28 validate.sh:27 Fix need_arg guard.
CR-afe9e2b7b7c0 validate.sh:80 Fix Switch to mktemp + -o $tmp + -w %{http_code} pattern; explicit HTTP status check before parsing; falls through to structured error output on 4xx/5xx.
CR-391c70408be0 validate.sh:83 Fix Response parsed in a single python3 block with try/except; missing result or bad JSON emits a {"status":"error","check":"hostname","detail":"..."} payload with the API detail, not a traceback.
CR-c5afb0714a2f auth-probe.sh:62 Fix GET-based probe (which authorizes Read-only tokens) replaced with POST-invalid-payload probe. Now: 401/403/err-code-10000missing_scope; 400/422/other 200 → scope_ok. Tests Turnstile:Edit specifically.
CR-1f8cc5f1475c auth-probe.sh:32 Fix command -v wrangler guard before invocation; when wrangler is not on PATH, fall back to $CLOUDFLARE_ACCOUNT_ID. Avoids npx install-prompt hang in non-interactive envs.
CR-30730b7afe35 fetch-secret.sh:39 Fix mktemp "${TMPDIR:-/tmp}/fetch-secret.XXXXXX" — explicit template for macOS/BSD portability. Same fix applied to all mktemp uses across the 5 scripts.
CR-2a35782321aa fetch-secret.sh:46 Fix Response parsed in a single python3 block using .get(..., default) for every dict access; non-JSON responses produce structured errors instead of tracebacks.
CR-e82849253dd7 fetch-secret.sh:29 Fix need_arg guard.
CR-76a6232130e9 fetch-secret.sh:41 Fix URL-encode account_id and sitekey via urllib.parse.quote before interpolation into the API URL. Applied consistently across widget-create, validate, and fetch-secret.
CR-03ee17a69501 persist-skill.sh:20 Fix need_arg guard.
CR-bb0ae4241b8d persist-skill.sh:50 Fix Build scripts JSON list via python3 os.listdir + json.dumps. Handles filenames with quotes, backslashes, and newlines safely.
CR-1bd6e05a63d9 (suggestion) fetch-secret.sh:43 Fix trap 'rm -f "$tmp"' EXIT immediately after mktemp. Applied to all scripts that use temp files.
CV-1717f75f5c6a PR-level Fix Updating the PR description to describe the 5 helper scripts and what they do.

All 5 scripts now pass bash -n and end-to-end smoke tests. Same content propagated to:

juleslemee pushed a commit to juleslemee/skills that referenced this pull request Jul 21, 2026
Replaces hand-escaped JSON with python3 json.dumps for both request
bodies and response outputs, adds argument-value validation before
each shift, structures HTTP error handling, and switches auth-probe
from a GET-based read probe to a POST-invalid-payload probe so it
tests Turnstile:Edit specifically.

widget-create.sh:
- python3 json.dumps for request body + success/error output
  (replaces `echo "{\"name\":\"$NAME\",...}"` which broke
  on names containing quotes, backslashes, or control characters)
- Argument value validation before shift
- Remove `2>/dev/null` on the API curl so network errors reach stderr
- URL-encode account_id

validate.sh:
- Argument value validation
- mktemp + -o + %{http_code} pattern for the widget-domains lookup
- python3 with structured error handling for JSON parse failures
- URL-encode account_id and sitekey

auth-probe.sh:
- `command -v wrangler` guard before invocation (avoids npx
  install-prompt hang in non-interactive envs); fall back to
  $CLOUDFLARE_ACCOUNT_ID when wrangler is not on PATH
- Replace GET-based scope probe with POST-invalid-payload probe:
  a GET would authorize a Read-only token and let the agent proceed
  to widget-create where it would fail. POST-invalid tests Edit
  specifically: 401/403/err-10000 -> missing_scope, 400/422 -> scope OK.
- mktemp with explicit template for macOS/BSD portability
- Argument value validation

fetch-secret.sh:
- Argument value validation
- mktemp with explicit template + trap-based cleanup on early exit
- Python fallbacks use .get() with structured error output on
  missing keys or non-JSON responses
- URL-encode account_id and sitekey

persist-skill.sh:
- Argument value validation
- Build scripts JSON list via python3 os.listdir + json.dumps
  (replaces ls | sed | paste which mishandled filenames with quotes,
  backslashes, or newlines)

Flagged by cloudflare-docs-bot on
cloudflare/cloudflare-docs#32147: 1 critical
(CR-22ef8f6771b1) + 15 warnings + 1 suggestion. All 19 addressed.

Companion: cloudflare-docs #32147, stratus MR !40817.
@juleslemee

Copy link
Copy Markdown
Contributor Author

Triaged the second-review findings. Skipping 2 with reasoning, fixing the other 9 in the pending commit.

Skipping

CR-04913c72363f (critical) — EXIT trap does NOT fire in $() subshells

The bot's claim: "The curl call runs inside a command substitution subshell, which inherits the trap. When that subshell exits, the trap deletes $tmp before the final python3 reads it."

That's not how bash EXIT traps work. Per the bash manual on subshell environments: "Traps caught by the shell are reset to the values inherited from the shell's parent." Command substitution runs in a subshell environment, so the parent's EXIT trap is not inherited by the $() subshell. The trap only fires when the outer script exits.

Verified locally:

$ cat > /tmp/trap-test.sh <<'END'
#!/usr/bin/env bash
set -uo pipefail
tmp=$(mktemp "${TMPDIR:-/tmp}/trap-test.XXXXXX")
trap 'echo "PARENT TRAP: rm $tmp"; rm -f "$tmp"' EXIT
echo "before curl: exists=$([ -f "$tmp" ] && echo yes || echo no)"
http_code=$(curl -sS -w "%{http_code}" -o "$tmp" https://api.cloudflare.com/client/v4 || echo "000")
echo "after curl: http_code=$http_code, exists=$([ -f "$tmp" ] && echo yes || echo no)"
[ -f "$tmp" ] && echo "size: $(wc -c < "$tmp") bytes"
END
$ bash /tmp/trap-test.sh
before curl: exists=yes
after curl: http_code=404, exists=yes
size: 105 bytes
PARENT TRAP: rm /var/folders/mq/.../trap-test.RibE8s

$tmp survives the curl subshell exit, python3 reads the response body successfully, and the trap only fires on the outer script's exit. My earlier end-to-end smoke tests also confirm this: widget-create.sh returned real HTTP 404 responses (parsed from $tmp), not the "non-JSON response" error path the bot predicted.

CR-14b4e5e6a596 (suggestion) — --path is user-supplied by design

persist-skill.sh --path <path> writes the skill bundle to a caller-chosen location. That's the tool's contract; the SKILL.md and stratus prompt tell the agent to choose the path based on the target agent runtime (.claude/skills/turnstile-spin/SKILL.md, .opencode/skills/turnstile-spin/SKILL.md, etc.). Constraining to .claude/skills/ would break support for other agent runtimes.

The bot's --path /etc/passwd example writes to /etc, which requires root; a user running as root has already opted into that. No new attack surface beyond what the caller already has file-system permission to do.

Fixing

ID File:Line Change
CR-8564af26b1eb validate.sh domain parsing [d.strip() for d in expected_csv.split(",") if d.strip()] — trims whitespace from each expected domain token.
CR-244ecc0a2bae fetch-secret.sh:97 (errors[0] or {}).get("message") — matches the defensive pattern already used for first_code. Applied consistently across all 5 scripts.
CR-170e734f63cc fetch-secret.sh:74 Explicit isinstance(data, dict) check after json.loads; emits structured error output when the response isn't a JSON object. Applied consistently across widget-create, validate, fetch-secret, and auth-probe.
CR-9cdbd7ec5a36 all scripts command -v python3 guard at the top of each script; emits the script's canonical error status (missing_token / error+reason:python3_not_available etc.) and exits 1 when python3 is absent.
CR-460039bb0738 (suggestion) all script headers Header comments now document all three exit codes: 0 on success, 1 on failure or missing prerequisite, 2 on invalid usage.
CR-3b31dbc5640a (suggestion) persist-skill.sh:63 Catch OSError (not just FileNotFoundError) so NotADirectoryError, PermissionError, and other listdir failures emit scripts: [] instead of a traceback. Verified locally with a scripts-is-a-file-not-a-directory case.
CR-4a512fac7815 auth-probe.sh:131 Bearer token now written to a mode-600 tempfile; curl reads it via -H "@$auth_headers". Token never appears in argv or ps output.
CR-333053976387 validate.sh:51 Turnstile secret now written to a mode-600 tempfile; curl reads it via --data-urlencode "secret@$secret_file". Secret never appears in argv. printf '%s' (no trailing newline) so the url-encoded value is exactly the secret.
CR-f14858b663da validate.sh:120 Same tempfile-header pattern as CR-4a512fac7815.

The same 9 fixes are applied byte-for-byte to cloudflare/skills#83 (canonical) and stratus MR !40817 (inlined into spinSkillContent.ts).

Jules Lemee added 2 commits July 21, 2026 17:14
Second round of docs-bot findings on this PR: 6 real issues + 3
secrets-in-argv warnings addressed. The bot's critical CR-04913c72363f
(EXIT trap fires in $() subshell) is a false positive; see triage
comment on this PR for the bash-test disproof. CR-14b4e5e6a596
(unvalidated --path) is skipped with reasoning in the same comment.

- CR-8564af26b1eb: strip whitespace from expected-domains tokens
- CR-244ecc0a2bae: (errors[0] or {}).get() defensive guard
- CR-170e734f63cc: isinstance(data, dict) after json.loads
- CR-9cdbd7ec5a36: 'command -v python3' guard at top of each script
- CR-460039bb0738: document exit 2 for invalid usage in headers
- CR-3b31dbc5640a: catch OSError instead of just FileNotFoundError
- CR-4a512fac7815, CR-333053976387, CR-f14858b663da: Bearer token and
  Turnstile secret now go through mode-600 tempfiles (curl -H @file /
  --data-urlencode name@file); never in argv.

Same 9 fixes propagated to cloudflare/skills#83 and stratus MR !40817.
Docs-bot third-round review flagged four items:

- Unchecked mktemp in widget-create.sh (two calls). Applied
  inline mktemp guards across all mktemp calls in
  widget-create.sh, validate.sh, fetch-secret.sh, and
  auth-probe.sh. On failure the scripts emit structured error
  JSON matching the existing contract and clean up any earlier
  tempfiles before exit.

- fetch-secret.sh accessed errors[0] without first checking
  that errors is a list. Added isinstance(errors, list) guard
  and applied the same fix to widget-create.sh, validate.sh,
  and auth-probe.sh which have the same pattern.

- persist-skill.sh had a python3 command -v guard but not one
  for npx (needed for the degit call). Added the npx guard
  next to it, returning a structured error JSON on absence.
@juleslemee

Copy link
Copy Markdown
Contributor Author

Third-round docs-bot review addressed — all 4 warnings fixed, no skips this round.

Findings addressed

  • CR-bb6142fcc2a0, CR-f0d5f69eb45d (widget-create.sh:71-72) — unchecked mktemp. Applied inline mktemp || { echo ...; exit 1; } guard on both $tmp and $auth_headers in widget-create.sh, and to every other mktemp call across validate.sh, fetch-secret.sh, and auth-probe.sh for consistency. On mktemp failure the scripts now emit a structured error JSON ({"status":"error","code":0,"message":"mktemp failed"} for widget-create; script-appropriate shape elsewhere) and exit non-zero, matching the existing error-JSON contract the rest of the pipeline consumes. Prior tempfiles are cleaned up before exit to avoid leaks.

  • CR-b563fb92b463 (fetch-secret.sh:92) — errors[0] on non-list. Added if not isinstance(errors, list): errors = [] before the errors[0] access. Same guard added to widget-create.sh, validate.sh, and auth-probe.sh which have the same pattern, so all four are defensive against "errors": {...} or "errors": "string" shapes.

  • CR-3c9c1a21df53 (persist-skill.sh) — missing command -v npx check. persist-skill.sh already had a command -v python3 guard; added the matching command -v npx guard right after it. Both return structured error JSON ({"status":"error","reason":"npx_not_available"}) and exit 1 before touching any of the downstream logic that would blow up on a missing dependency.

Verification

  • bash -n clean on all 5 scripts.
  • widget-create.sh with TMPDIR=/proc/does-not-exist returns the expected structured error (mktemp guard fires).
  • persist-skill.sh with a PATH that excludes npx returns {"status":"error","reason":"npx_not_available"} (new guard fires).
  • persist-skill.sh with npx present continues into the normal degit flow.
  • The python heredoc guards are silent no-ops on the well-formed responses the API actually returns; they only kick in on malformed shapes.

Same set of changes synced to the stratus dashboard inline copy (regenerated on the token-reset branch, extracted matches canonical byte-for-byte).

Note: this PR has already used both of the auto-review passes docs-bot allocates per PR. If a codeowner wants a fresh full review over these changes, comment /full-review and it will kick another pass.

Jules Lemee added 3 commits July 22, 2026 13:30
Docs-bot and gsa_claude flagged that the Edit-scope probe POSTs a
widget-create request with intentionally-invalid empty name/domains
to distinguish Read scope (401/403) from Edit scope (400/422). The
API rejects the payload today, so no widget is created, but there
was no safety net if validation ever loosened.

Parse result.sitekey from the probe response and, if a widget was
unexpectedly created (success:true with a sitekey), fire a DELETE
against it so the probe stays side-effect-free regardless of future
API validation changes. Verdict logic and existing 400/422/200/401/
403 branches are unchanged.
Mirror the same fix from the cloudflare-skills canonical: Step 2 (CLI
check) and Troubleshooting row for `wrangler whoami` now describe the
actual auth-probe.sh behavior (PATH-resident wrangler, fallback to
$CLOUDFLARE_ACCOUNT_ID, no `/accounts` curl because the token is
scoped to Account.Turnstile:Edit only).
The dashboard's inlined SPIN_SKILL_CONTENT in stratus had accumulated
edits (32.5KB) that were never mirrored back to this docs copy (19.5KB
before this commit). Copying the stratus template-literal body into
prompt.md so both surfaces ship the same skill.

Follow-up: stratus will switch to fetching this URL at runtime and
drop the inline mirror, at which point prompt.md becomes the single
source of truth.
@marinaelmore

Copy link
Copy Markdown
Collaborator

/rebase

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants